<!DOCTYPE html>
<!--
Copyright 2016 The Chromium Authors. All rights reserved.
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file.
-->

<link rel="import" href="/tracing/base/timing.html">
<link rel="import" href="/tracing/ui/base/table.html">
<link rel="import" href="/tracing/value/histogram_set.html">
<link rel="import" href="/tracing/value/histogram_set_hierarchy.html">
<link rel="import" href="/tracing/value/ui/histogram_set_table_row.html">
<link rel="import" href="/tracing/value/ui/histogram_set_view_state.html">

<dom-module id="tr-v-ui-histogram-set-table">
  <template>
    <style>
    :host {
      min-height: 0px;
      overflow: auto;
    }
    #table {
      margin-top: 5px;
    }
    </style>

    <tr-ui-b-table id="table"/>
  </template>
</dom-module>

<script>
'use strict';
tr.exportTo('tr.v.ui', function() {
  const MIDLINE_HORIZONTAL_ELLIPSIS = String.fromCharCode(0x22ef);

  // http://stackoverflow.com/questions/3446170
  function escapeRegExp(str) {
    return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
  }

  Polymer({
    is: 'tr-v-ui-histogram-set-table',

    created() {
      this.viewState_ = undefined;
      this.progress_ = () => Promise.resolve();
      this.nameColumnTitle_ = undefined;
      this.displayLabels_ = [];
      this.histograms_ = undefined;
      this.sourceHistograms_ = undefined;
      this.filteredHistograms_ = undefined;
      this.groupedHistograms_ = undefined;
      this.hierarchies_ = undefined;
      this.tableRows_ = undefined;

      // Store this listener so it can be removed while updateContents_ modifies
      // sortColumnIndex and sortDescending, then re-added.
      this.sortColumnChangedListener_ = e => this.onSortColumnChanged_(e);
    },

    ready() {
      this.$.table.zebra = true;
      this.addEventListener('sort-column-changed',
          this.sortColumnChangedListener_);
      this.addEventListener('requestSelectionChange',
          this.onRequestSelectionChange_.bind(this));
      this.addEventListener('row-expanded-changed',
          this.onRowExpandedChanged_.bind(this));
    },

    get viewState() {
      return this.viewState_;
    },

    set viewState(vs) {
      if (this.viewState_) {
        throw new Error('viewState must be set exactly once.');
      }
      this.viewState_ = vs;
      this.viewState.addUpdateListener(this.onViewStateUpdate_.bind(this));
      // It would be arduous to construct a delta and call onViewStateUpdate_
      // here in case vs contains non-default values, so callers must set
      // viewState first and then update it.
    },

    get histograms() {
      return this.histograms_;
    },

    /**
     * @param {!tr.v.HistogramSet} histograms
     * @param {!tr.v.HistogramSet} sourceHistograms
     * @param {!Array.<string>} displayLabels
     * @param {function(string, function())=} opt_progress
     */
    async build(histograms, sourceHistograms, displayLabels, opt_progress) {
      this.histograms_ = histograms;
      this.sourceHistograms_ = sourceHistograms;
      this.filteredHistograms_ = undefined;
      this.groupedHistograms_ = undefined;
      this.displayLabels_ = displayLabels;

      if (opt_progress !== undefined) this.progress_ = opt_progress;

      if (histograms.length === 0) {
        throw new Error('histogram-set-table requires non-empty HistogramSet.');
      }

      await this.progress_('Building columns...');
      this.$.table.tableColumns = [
        {
          title: this.buildNameColumnTitle_(),
          value: row => row.nameCell,
          cmp: (a, b) => a.compareNames(b),
        }
      ].concat(displayLabels.map(l => this.buildColumn_(l)));

      tr.b.Timing.instant('histogram-set-table', 'columnCount',
          this.$.table.tableColumns.length);

      // updateContents_() displays its own progress.
      await this.updateContents_();

      // Building some elements requires being able to measure them, which is
      // impossible until they are displayed. If clients hide this table while
      // it is being built, then they must display it when this event fires.
      this.fire('display-ready');

      this.progress_ = () => Promise.resolve();

      this.checkNameColumnOverflow_(
          tr.v.ui.HistogramSetTableRow.walkAll(this.$.table.tableRows));
    },

    buildNameColumnTitle_() {
      this.nameColumnTitle_ = document.createElement('span');
      this.nameColumnTitle_.style.display = 'inline-flex';

      // Wrap the string in a span instead of using createTextNode() so that the
      // span can be styled later.
      const nameEl = document.createElement('span');
      nameEl.textContent = 'Name';
      this.nameColumnTitle_.appendChild(nameEl);

      const toggleWidthEl = document.createElement('span');
      toggleWidthEl.style.fontWeight = 'bold';
      toggleWidthEl.style.background = '#bbb';
      toggleWidthEl.style.color = '#333';
      toggleWidthEl.style.padding = '0px 3px';
      toggleWidthEl.style.marginRight = '8px';
      toggleWidthEl.style.display = 'none';
      toggleWidthEl.textContent = MIDLINE_HORIZONTAL_ELLIPSIS;
      toggleWidthEl.addEventListener('click',
          this.toggleNameColumnWidth_.bind(this));
      this.nameColumnTitle_.appendChild(toggleWidthEl);
      return this.nameColumnTitle_;
    },

    toggleNameColumnWidth_(opt_event) {
      this.viewState.update({
        constrainNameColumn: !this.viewState.constrainNameColumn,
      });

      if (opt_event !== undefined) {
        opt_event.stopPropagation();
        opt_event.preventDefault();
        tr.b.Timing.instant('histogram-set-table', 'nameColumn' +
            (this.viewState.constrainNameColumn ? 'Constrained' :
              'Unconstrained'));
      }
    },

    buildColumn_(displayLabel) {
      const title = document.createElement('span');
      title.textContent = displayLabel;
      title.style.whiteSpace = 'pre';

      return {
        displayLabel,
        title,
        value: row => row.getCell(displayLabel),
        cmp: (rowA, rowB) => rowA.compareCells(rowB, displayLabel),
      };
    },

    async updateContents_() {
      const previousRowStates = this.viewState.tableRowStates;

      if (!this.filteredHistograms_) {
        await this.progress_('Filtering rows...');
        this.filteredHistograms_ = this.viewState.showAll ?
          this.histograms : this.sourceHistograms_;

        if (this.viewState.searchQuery) {
          let query;
          try {
            query = new RegExp(this.viewState.searchQuery);
          } catch (e) {
          }
          if (query !== undefined) {
            this.filteredHistograms_ = new tr.v.HistogramSet(
                [...this.filteredHistograms_].filter(
                    hist => hist.name.match(query)));
            if (this.filteredHistograms_.length === 0 &&
                !this.viewState.showAll) {
              await this.viewState.update({showAll: true});
              return;
            }
          }
        }
        this.groupedHistograms_ = undefined;
      }

      if (!this.groupedHistograms_) {
        await this.progress_('Grouping Histograms...');
        this.groupHistograms_();
      }

      if (!this.hierarchies_) {
        await this.progress_('Merging Histograms...');
        this.hierarchies_ = tr.v.HistogramSetHierarchy.build(
            this.groupedHistograms_);
        this.tableRows_ = undefined;
      }

      const tableRowsDirty = this.tableRows_ === undefined;
      if (tableRowsDirty) {
        // Wait to set this.$.table.tableRows until we're ready for it to build
        // DOM. When tableRows are set on it, tr-ui-b-table calls
        // setTimeout(..., 0) to schedule rebuild for the next interpreter tick,
        // but that can happen in between the next await, which is too early.
        this.tableRows_ = this.hierarchies_.map(hierarchy =>
          new tr.v.ui.HistogramSetTableRow(
              hierarchy, this.$.table, this.viewState));

        tr.b.Timing.instant('histogram-set-table', 'rootRowCount',
            this.tableRows_.length);

        const namesToRowStates = new Map();
        for (const row of this.tableRows_) {
          namesToRowStates.set(row.name, row.viewState);
        }
        await this.viewState.update({tableRowStates: namesToRowStates});
      }

      await this.progress_('Configuring table...');
      this.nameColumnTitle_.children[1].style.filter =
        this.viewState.constrainNameColumn ? 'invert(100%)' : '';

      const referenceDisplayLabelIndex = this.displayLabels_.indexOf(
          this.viewState.referenceDisplayLabel);
      this.$.table.selectedTableColumnIndex = (referenceDisplayLabelIndex < 0) ?
        undefined : (1 + referenceDisplayLabelIndex);

      // Temporarily stop listening for this event in order to prevent the
      // listener from updating viewState unnecessarily.
      this.removeEventListener('sort-column-changed',
          this.sortColumnChangedListener_);
      this.$.table.sortColumnIndex = this.viewState.sortColumnIndex;
      this.$.table.sortDescending = this.viewState.sortDescending;
      this.addEventListener('sort-column-changed',
          this.sortColumnChangedListener_);

      // Each name-cell listens to this.viewState for updates to
      // constrainNameColumn.
      // Each table-cell listens to this.viewState for updates to
      // displayStatisticName and referenceDisplayLabel.

      if (tableRowsDirty) {
        await this.progress_('Building DOM...');
        this.$.table.tableRows = this.tableRows_;

        // Try to restore previous row state.
        // Wait to do this until after the base table has the new rows so that
        // setExpandedForTableRow doesn't get confused.
        for (const row of this.tableRows_) {
          const previousState = previousRowStates.get(row.name);
          if (!previousState) continue;
          await row.restoreState(previousState);
        }
      }

      // It's always safe to call this, it will only recompute what is dirty.
      // We want to make sure that the table is up to date when this async
      // function resolves.
      this.$.table.rebuild();
    },

    async onRowExpandedChanged_(event) {
      event.row.viewState.isExpanded =
        this.$.table.getExpandedForTableRow(event.row);
      tr.b.Timing.instant('histogram-set-table',
          'row' + (event.row.viewState.isExpanded ? 'Expanded' : 'Collapsed'));

      // When the user expands a row, the table builds subRows' name-cells.
      // If a subRow's name isOverflowing even though none of the top-level rows
      // are constrained, show the dots to allow the user to unconstrain the
      // name column.
      // Each name-cell.isOverflowing would force layout if we don't await
      // animationFrame here, which would be inefficient.
      if (this.nameColumnTitle_.children[1].style.display === 'block') return;
      await tr.b.animationFrame();
      this.checkNameColumnOverflow_(event.row.subRows);
    },

    checkNameColumnOverflow_(rows) {
      for (const row of rows) {
        if (!row.nameCell.isOverflowing) continue;

        const [nameSpan, dots] = Array.from(this.nameColumnTitle_.children);
        dots.style.display = 'block';

        // Size the span containing 'Name' so that the dots align with the
        // ellipses in the name-cells.
        const labelWidthPx = tr.v.ui.NAME_COLUMN_WIDTH_PX -
          dots.getBoundingClientRect().width;
        nameSpan.style.width = labelWidthPx + 'px';

        return;
      }
    },

    groupHistograms_() {
      const groupings = this.viewState.groupings.slice();
      groupings.push(tr.v.HistogramGrouping.DISPLAY_LABEL);

      function canSkipGrouping(grouping, groupedHistograms) {
        // Never skip meaningful groupings.
        if (groupedHistograms.size > 1) return false;

        // Never skip the zero-th grouping.
        if (grouping.key === groupings[0].key) return false;

        // Never skip the grouping that defines the table columns.
        if (grouping.key === tr.v.HistogramGrouping.DISPLAY_LABEL.key) {
          return false;
        }

        // Skip meaningless groupings.
        return true;
      }

      this.groupedHistograms_ =
        this.filteredHistograms_.groupHistogramsRecursively(
            groupings, canSkipGrouping);

      this.hierarchies_ = undefined;
    },

    /**
     * @param {!tr.b.Event} event
     * @param {!Object} event.delta
     * @param {!Object} event.delta.searchQuery
     * @param {!Object} event.delta.referenceDisplayLabel
     * @param {!Object} event.delta.displayStatisticName
     * @param {!Object} event.delta.showAll
     * @param {!Object} event.delta.groupings
     * @param {!Object} event.delta.sortColumnIndex
     * @param {!Object} event.delta.sortDescending
     * @param {!Object} event.delta.constrainNameColumn
     * @param {!Object} event.delta.tableRowStates
     */
    async onViewStateUpdate_(event) {
      if (this.histograms_ === undefined) return;

      if (event.delta.searchQuery !== undefined ||
          event.delta.showAll !== undefined) {
        this.filteredHistograms_ = undefined;
      }

      if (event.delta.groupings !== undefined) {
        this.groupedHistograms_ = undefined;
      }

      if (event.delta.displayStatistic !== undefined &&
          this.$.table.sortColumnIndex > 0) {
        // Force re-sort.
        this.$.table.sortColumnIndex = undefined;
      }

      if (event.delta.referenceDisplayLabel !== undefined ||
          event.delta.displayStatisticName !== undefined) {
        // Force this.$.table.bodyDirty_ = true;
        this.$.table.tableRows = this.$.table.tableRows;
      }

      // updateContents_() always copies sortColumnIndex and sortDescending
      // from the viewState to the table. The table will only re-sort if
      // they change.

      // Name-cells listen to this.viewState to handle updates to
      // constrainNameColumn.

      if (event.delta.tableRowStates) {
        if (this.tableRows_.length !==
            this.viewState.tableRowStates.size) {
          throw new Error(
              'Only histogram-set-table may update tableRowStates');
        }
        for (const row of this.tableRows_) {
          if (this.viewState.tableRowStates.get(row.name) !== row.viewState) {
            throw new Error(
                'Only histogram-set-table may update tableRowStates');
          }
        }
        return; // No need to re-enter updateContents_().
      }

      await this.updateContents_();
    },

    onSortColumnChanged_(event) {
      tr.b.Timing.instant('histogram-set-table', 'sortColumn');
      this.viewState.update({
        sortColumnIndex: event.sortColumnIndex,
        sortDescending: event.sortDescending,
      });
    },

    onRequestSelectionChange_(event) {
      // This event may reference an EventSet or an array of Histogram names.
      // If EventSet, let the BrushingStateController handle it.
      if (event.selection instanceof tr.model.EventSet) return;

      event.stopPropagation();
      tr.b.Timing.instant('histogram-set-table', 'selectHistogramNames');

      let histogramNames = event.selection;
      histogramNames.sort();
      histogramNames = histogramNames.map(escapeRegExp).join('|');
      this.viewState.update({
        showAll: true,
        searchQuery: `^(${histogramNames})$`,
      });
    },

    /**
     * @return {!tr.v.HistogramSet}
     */
    get leafHistograms() {
      const histograms = new tr.v.HistogramSet();
      for (const row of
        tr.v.ui.HistogramSetTableRow.walkAll(this.$.table.tableRows)) {
        if (row.subRows.length) continue;
        for (const hist of row.columns.values()) {
          if (!(hist instanceof tr.v.Histogram)) continue;

          histograms.addHistogram(hist);
        }
      }
      return histograms;
    }
  });

  return {
    MIDLINE_HORIZONTAL_ELLIPSIS,
  };
});
</script>
